...loading
2024-12-02
리액트로 프로그래밍하다 보면 여러가지 훅들을 사용해보고 알게된다. 그런데 개인적인 경험으로는 자신이 이 훅을 잘 이해하고 쓰는게 맞는지 의심갈 때가 많다. 또한 특정 훅 보다 성능이나 기능적으로 더 좋은 선택지가 있는데, 타성에 의해 고민하지 않고 사용하던 훅들만 사용하는 경우도 분명 있었을 것이다. 이번 포스팅을 통해 훅들을 좀 더 제대로 살펴보고, 실무에서 유용하게 사용하기 위한 기틀을 마련하고자 한다.
useState는 가장 기본적인 hook으로 리액트 개발자가 가장 많이 쓰는 hook일 것이다. 기본적인 사용방법은 아래와 같다.
import {useState} from "react"; const MyComponent = () =>{ const [state, setState] = useState(initialState); return( <div> ... <div/> ) }
useState의 인수를 통해 초기값을 지정해주고, setState 함수를 통해 해당 상태값을 업데이트할 수 있다. 그리고 setState함수가 호출되면 해당 훅이 사용된 컴포넌트는 리렌더링 된다. 그런데 여기서 흥미로운 포인트가 있다. 리액트의 렌더링은 함수형 컴포넌트에서 반환한 결과물인 return의 값을 비교하여 실행된다. 이는 렌더링이 발생할 때 마다 함수가 새롭게 실행된다는 것인데, 새롭게 실행된 함수 컴포넌트 내부의 state값 또한 초기화되는 것이 정상이다. 그러나 setState함수를 통해 렌더링이 발생하더라도, setState함수는 지역변수라 볼 수 있는 state값을 계속하여 참조한다. 리액트는 이를 클로저를 통해 구현했다고 짐작되고 있다. 여기서 클로저란 useState 내부의 setState함수가 종료되었음에도 불구하고, 지역변수인 state의 최신 값을 기억하고 참조하는 것을 의미한다.
useEffect 또한 useState만큼이나 자주 쓰는 훅이다. useEffect는 생명주기 메서드와 비슷한 작동을 구현할 수 있지만, 이를 위해 만들어진 기능은 아니다. useEffect는 컴포넌트 내 여러 값들을 활용해 동기적으로 부수 효과를 만드는 기능이다. 그리고 이 부수 효과는 컴포넌트 내의 상태값과 함께 실행된다.
const MyComponent = () => { // ... useEffect(()=>{ // callback },[props, state]) // ... }
위의 코드가 useEffect의 기본적인 코드다. 익히 알고 있듯이 의존성 배열 내부의 값이 변경되면 콜백함수가 실행된다. useEffect가 의존성 배열 내부의 값의 변화를 감지하는 방법은 다음과 같다. 리액트의 함수형 컴포넌트는 렌더링 시마다 고유의 props와 state값들을 가지고 있다. useEffect는 컴포넌트가 렌더링 될 때마다 이 고유값들을 체크하면서, 이전과 다르다면 부수 효과를 실행하게 된다.
클린업 함수는 useEffect에 이벤트를 추가했을 때, 추가된 이벤트를 지워줄 때 주로 사용하는 함수다.
const MyComponent = () => { // ... useEffect(()=>{ window.addEventListener(...) return () => { window.removeEventListener(...) } },[props, state]) // ... }
위의 코드가 간단한 사용예시다. useEffect는 렌더링 시, 콜백을 시작하기 전에 이전의 클린업 함수가 존재한다면 해당 클린업 함수를 실행한 후 콜백을 실행한다. 따라서 이벤트를 추가했을 시, 이전에 등록했던 이벤트 핸들러를 클린업 함수를 통해 삭제하는 것이다. 만약 이벤트를 삭제하지 않는다면 이벤트 핸들러가 계속하여 추가될 것이다.
혹자는 이러한 클린업 함수가 생명주기 메서드의 언마운트와 유사하다고 본다. 그러나, 클린업 함수가 언마운트 메서드와 비슷한 기능을 할 수는 있지만 두 기능은 다르다고 볼 수 있다. 엄밀히 말해 클린업 함수는 상태 변화로 인해 리렌더링이 발생했을 시, 이전 상태에 의존한 환경을 청소해주는 개념이다.
의존성 배열은 보통 빈 배열을 두거나, 사용자가 원하는 값을 넣거나, 아예 배열 자체를 넣지 않을 수 있다. useEffect의 의존성에 빈 배열이 있을 경우, 리액트는 비교할 의존성이 없다고 판단하여 컴포넌트의 최초 렌더링 이후부터는 useEffect의 콜백이 실행되지 않는다. 배열에 값을 넣는다면 위에서 설명한 바와 같이 값의 변동성에 따라 콜백함수가 재실행된다. 마지막으로 의존성 배열 자체를 넣지 않을 경우, 리액트는 의존성을 비교할 필요 없이 렌더링 마다 콜백이 실행되어야 한다고 판단한다. 따라서 렌더링이 발생할 때마다 콜백이 실행된다.
의존성 배열을 아예 넣지 않은 경우를 생각해보자. 이 경우 컴포넌트가 렌더링 될 때 마다 콜백이 실행되었다. 그렇다면 이러한 상황에서는 굳이 useEffect를 쓰지 않고 함수를 즉시 실행해도 되지 않을까? 두 방법 사이에는 어떠한 차이가 있을까. 아래의 코드 예시를 살펴보며 useEffect와 직접실행의 차이를 살펴보자.
// useEffect 사용 X fucntion Component02() { console.log('This component is rendered!'); } // useEffect 사용 O fucntion Component01() { useEffect(()=>{ console.log('This component is rendered!'); }) }
위 두 코드의 결과는 같아 보일지 몰라도 분명한 차이가 존재한다. 직접 실행을 명령한 1 번째 코드는 서버 사이드 렌더링의 경우 서버에서도 실행된다. 따라서 만약 직접 실행하는 코드가 복잡하다면 컴포넌트의 반환을 지연하게 된다. 반면 useEffect를 사용한 2 번째 코드의 경우, 렌더링이 완료된 이후 브라우저 측에서만 실행된다. useEffect는 렌더링 이후의 부수 효과를 실행하는 기능이기 때문이다.
useMemo는 비용이 큰 연산의 결과를 저장해두는 훅이다. 최적화를 위한 리액트의 훅으로 useEffect와 같이 의존성 배열을 사용하며 해당 값의 비교(렌더링 이전값과 현재값 비교)를 통해 재연산/저장의 여부가 결정된다. useMemo를 사용하게 되는 시나리오는 보통 다음과 같은 경우다.
- 자주 업데이트되지 않은 props를 가진 컴포넌트
- 부모 컴포넌트에 의해 불필요하게 리렌더링되는 자식 컴포넌트
- 렌더링 최적화가 필요한 대규모 리스트
다음은 useMemo를 사용한 예시 코드를 살펴보겠다.
const expensiveCalc = (prop) => { return prop ** prop; } // No memoization function Component01({prop}) { const result = expensiveCalc(prop); return( <p> Result is {result} </p> ) } // Memoization function Component02({prop}) { const result = useMemo(()=> expensiveCalc(prop) ,[prop]); return( <p> Result is {result} </p> ) }
위의 코드는 useMemo을 통한 메모이제이션의 예시다. 첫 번째 컴포넌트는 연산을 즉시 실행하여 컴포넌트가 렌더링될 때마다 시행되도록 하였다. 하지만 이는 비효율적이다. expensiveCalc 함수는 prop인자를 받는 함수이기에 prop이 변경될 때만 시행되면 되기 때문이다. 이러한 경우 useMemo 훅을 사용한면 비효율성을 개선하고 성능에 도움을 줄 수 있다. Component02가 useMemo를 사용한 예시다. useMemo의 의존성 배열에 prop을 전달했다. 따라서 Component02가 리렌더링 된다고 하더라도, prop이 변경되지 않은 이상 result는 useMemo를 통해 저장된 값으로 반환된다.
useMemo는 위의 예시와 같이 불필요한 연산을 최적화해주는 기능이다. 따라서 적절히 사용한다면 웹의 성능 최적화에 도움을 줄 수 있다. 그러나 모든 연산에 useMemo를 적용하는 것은 바람직하지 않을 수 있다. useMemo 또한 의존성 값을 비교하는 연산을 거친다. 즉 메모이제이션 또한 계산이기 때문에 간단한 작업들까지 섣불리 useMemo를 적용할 필요는 없다. 성능 개선의 여지가 있을 작업들에 useMemo를 적용함으로써 최적화를 도모하는 것이 웹의 성능에 긍정적인 영향을 미칠 수 있다.
useCallback 또한 메모이제이션을 통해 컴포넌트의 성능을 최적화하는 기능이다. 앞서 살펴본 useMemo에서는 콜백함수의 리턴값을 메모이제이션 해주었다. 반면 useCallback은 콜백함수 그 자체를 메모이제이션한다. 즉 특정함수를 새로 만들지 않고 재사용하기 위해 만들어진 훅이다.
const cachedFn = useCallback(fn, dependencies)
: useMemo와 유사한 구조를 가진다. 2번째 매개변수로 의존성 배열을 받는다. 의존성 배열이 존재하지 않는 경우 useCallback은 의미가 없어진다. 빈배열을 넣을 경우, 컴포넌트의 첫 마운트 시 한 번만 콜백 함수 객체가 생성되며 메모라이징된다. 의존성 배열에 특정 변수를 넣을 경우, 렌더링 시 비교를 통해 함수 메모라이징이 업데이트 된다.
- 자식 컴포넌트에 콜백 함수 전달 시, 자식 컴포넌트가 불필요하게 재렌더링되는 것을 방지
- 의존성 배열의 값이 자주 변경되지 않는 경우 성능 최적화
const ChildComponent = memo(({ onClick }) => { console.log("Child 렌더링"); return <button onClick={onClick}>Click me!</button>; }); const ParentComponent = () => { const [count, setCount] = useState(0); const increment = useCallback(() => setCount((prev) => prev + 1), []); return <Child onClick={increment} />; };
위 코드는 useCallback 훅의 사용예시다. useCallback이 사용된 부모 컴포넌트 먼저 살펴보자. 부모 컴포넌트는 useCallback을 통해 최초 렌더링 시, increment 함수를 메모이제이션한다. 의존성 인자에 빈 배열을 사용했으므로, 해당 함수는 컴포넌트의 생명주기 동안 동일하게 유지된다.
이제 자식 컴포넌트를 살펴보자. 자식 컴포넌트는 memo API를 통해 선언된 컴포넌트로 props가 변경되지 않는 한 부모에 의한 리렌더링이 발생하지 않는다. 한편 자식 컴포넌트는 props로 increment함수를 전달받고 있다. 그리고 앞서 보았듯이, increment는 useCallback을 통해 메모이제이션된 함수가 전달되고 있다. 그렇기에 불필요한 렌더링이 막아지고 있다.
만약 부모 컴포넌트에서 increment함수에 useCallback을 적용하지 않았다면 어떻게 되었을까. 부모 컴포넌트가 렌더링될 때마다, increment 함수 객체는 같은 내용임에도 불구하고 계속해서 재생성된다. 그리고 결과적으로 자식 컴포넌트 또한 변화가 없음에도 리렌더링이 발생해버린다.
memo는 리액트의 훅은 아니지만 메모이제이션 훅을 살펴보며 알아보면 좋을 리액트의 대표적인 메모이제이션 API다. memo를 사용하면 컴포넌트의 props가 변경되지 않은 경우 리렌더링을 건너뛸 수 있다. 리액트에서의 컴포넌트는 부모 컴포넌트가 리렌더링될 때 마다 리렌더링된다. 만약 컴포넌트의
const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
Comments